feat(lineage): tell concurrent Cursor MCP calls apart by the arguments the pre-tool hook recorded (#424) - #483
Conversation
…s the pre-tool hook recorded (#424)
🦋 Changeset detectedLatest commit: c09abbd The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: af898c99fe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| input, | ||
| context, | ||
| { clientName, lineage: await toolCallLineage(options.lineage, context, route.name, clientName, options.lineageHost) }, | ||
| { clientName, lineage: await toolCallLineage(options.lineage, context, route.name, input, clientName, options.lineageHost) }, |
There was a problem hiding this comment.
Correlate against raw MCP arguments
When a generated route's input schema supplies defaults, the registerTool callback receives schema-parsed input rather than the raw MCP arguments recorded by Cursor's hook. For example, the existing layout-probe schema maps both raw {} and raw { "label": "probe" } to { "label": "probe" }; if those calls are concurrent in different conversations, their hook digests differ but this line gives both requests the second digest, silently attributing the omitted-argument call to the wrong conversation. Capture the raw request arguments before schema parsing instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c09abbd. The generated server now captures every tools/call's params.arguments off the wire (wrapping the transport's onmessage when the server connects, keyed by JSON-RPC request id) and hands that raw value to resolveToolCall; the schema-parsed callback input is never compared. When no raw capture exists for a request (a transport the server did not connect itself) the arguments property is omitted and no narrowing happens. Regression test: mcp-server-runtime.test.ts registers a tool with label: z.string().default('probe') and asserts the registry receives {}, { label: 'probe' }, and { label: 'other' } verbatim.
| if (conversations(matches) > 1 && argumentsDigest !== undefined) { | ||
| narrowed = matches.filter((candidate) => candidate.inputDigest === argumentsDigest); | ||
| } |
There was a problem hiding this comment.
Keep legacy undigested windows ambiguous
After upgrading a durable registry that still contains an open pre-upgrade window, that window has no inputDigest because the field remains optional for v1 compatibility. If a new window for the same tool opens in another conversation with identical arguments, this filter discards the legacy contender and treats the new conversation as uniquely matched, even though either window could own the request. Any competing window without a digest must preserve id-not-resolvable rather than allowing attribution.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c09abbd. A candidate window without an inputDigest (pre-upgrade journal, or a hook whose tool_input was not an object) is never filtered out: narrowed keeps every undigested window alongside the digest matches, so a digested competitor in another conversation leaves the call id-not-resolvable. Regression test: lineage-registry.test.ts 'keeps a window without a recorded digest in contention' — legacy root-a window plus digested root-b → refused; once root-b closes the legacy window resolves alone.
… windows in contention (review)
Summary
Framework-side support for gap 3 of #424 (Cursor
tools/call_metacarries no conversation or tool-call id). No vendor contact; nothing is fabricated — the change only narrows an existing refusal using a fact both payloads already carry.Gap (captured evidence)
fixtures/host-lineage/cursor-3.18.25.ndjson, live Cursor 3.18.25 desktop capture (docs/audits/2026-09-03-host-lineage-matrix.md§3):preToolUse,tool_name: "MCP:probe",tool_input: {"note":"subagent"},conversation_id: bf617dfd….tools/call:_meta: { progressToken: 1 }only, clientcursor-vscode 1.0.0, argumentsnote: "subagent".46efda32…) repeats the pattern with{"note":"nested"}.So the hook's
tool_inputis the call's arguments, verbatim, while_metanames nothing. Until now the registry matched Cursor calls only by the openMCP:<tool>window's tool name and refused (id-not-resolvable) whenever several conversations had the same tool open — the parallel-worker case.What changes
packages/rsc-runtime/src/lineage/state.ts—OpenToolCall.inputDigest?(sha256 prefix of the canonicaltool_input; optional, so existing journals read unchanged, no state version bump).packages/rsc-runtime/src/lineage/registry.ts—tool/beforerecords the digest;LineageToolCallQuery.arguments?; the name-match fallback narrows by digest only when the matches span several conversations. One conversation left → resolves (resolution: 'inferred',source: 'derived'); zero or several → stillid-not-resolvable. A lone open conversation is never made ambiguous by a digest mismatch, andundefined/{}arguments digest alike.packages/agent-bundle/src/mcp-server-runtime.ts— the generated server passes the toolinputtoresolveToolCall.cursor-2026-08-28.jsonlineage.mcp-correlationreason + evidence (row staysdegraded:_metastill carries nothing natively);docs/entry-conventions.mdhost table.Claude keeps its exact
claudecode/toolUseIdpath and Codex its_metalineage; the narrowing only applies to the shared name-match fallback.Tests
packages/rsc-runtime/tests/lineage-registry.test.ts: two Cursor conversations withMCP:probeopen using the captured{"note":"subagent"}/{"note":"nested"}inputs — no arguments → refused (unchanged), matching arguments → the right conversation, unrecorded arguments → refused, identical{}inputs in both → refused, a lone window resolves regardless of arguments. Existing 45 lineage tests unchanged.pnpm typecheck,pnpm lintgreen.Review status
af898c9(PR opened): P1 — correlate against raw wire arguments, not schema-parsed input; P2 — a pre-upgrade window without a digest must stay in contention. Both addressed inc09abbdc: the generated server capturestools/callparams.argumentsoff the wire (per request id, consumed once by the tool callback) and omitsargumentsentirely when nothing was captured; undigested windows are never filtered out. Tests:mcp-server-runtime.test.ts(raw{}vs{ label: 'probe' }with a defaulting schema),lineage-registry.test.ts(legacy window keeps the callid-not-resolvable).c09abbdc. Per maintainer instruction no review re-request is posted; merging on green CI.Refs #424.